package org.hackystat.devcathlon.engine.match;

import java.io.Serializable;
import java.text.DateFormat;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.HashMap;
import java.util.Set;
import java.util.Timer;
import java.util.Map.Entry;
import javax.xml.datatype.XMLGregorianCalendar;
import org.hackystat.dailyprojectdata.client.DailyProjectDataClient;
import org.hackystat.devcathlon.configuration.ConfigurationException;
import org.hackystat.devcathlon.configuration.ConfigurationManager;
import org.hackystat.devcathlon.configuration.jaxb.DevcathlonConfiguration;
import org.hackystat.devcathlon.engine.commentary.Comment;
import org.hackystat.devcathlon.engine.event.Event;
import org.hackystat.devcathlon.engine.event.EventManager;
import org.hackystat.devcathlon.engine.event.EventScore;
import org.hackystat.devcathlon.engine.project.ProjectManager;
import org.hackystat.devcathlon.engine.project.TeamProject;
import org.hackystat.devcathlon.engine.team.Team;
import org.hackystat.devcathlon.engine.user.User;
import org.hackystat.sensorbase.client.SensorBaseClient;
import org.hackystat.sensorbase.resource.projects.jaxb.Project;
import org.hackystat.utilities.tstamp.Tstamp;

/**
 * Provides information about a single match. Matches are managed (created, retrieved, etc.) via the
 * MatchManager.
 * 
 * @author Philip Johnson
 */
public class Match implements Serializable {
  /** Support Serialization. */
  private static final long serialVersionUID = 1L;
  /** Primary key reference id. */
  private Long id;
  /** The unique name for this match. */
  private String name;
  /** The start of the match. */
  private String startDateTime;
  /** The end of the match. */
  private String endDateTime;
  private Map<Team, TeamProject> teamProjects = new HashMap<Team, TeamProject>();
  /** The (team, project) pairs for this match. */
  private Map<Team, Project> teams2projects;
  /** The list of Events for this match. */
  private List<Event> events;
  /** Maps EventScores to their Events. */
  private Map<EventScore, Event> eventScoresMap = new HashMap<EventScore, Event>();
  /** Maps Events to their EventScores. */
  private Map<Event, List<EventScore>> events2scores = new HashMap<Event, List<EventScore>>();
  /** Maps the teams that accepted their invitation status. */
  private Map<Team, String> teamsInviteMap = new HashMap<Team, String>();
  /** The list of Events for this match. */
  private List<Comment> comments = new ArrayList<Comment>();
  
  /** Maps Events to their Timers. */
  private Map<Event, Timer> events2timers = new HashMap<Event, Timer>();
  

  /** Maps teams to their SensorBaseClient instances. */
  private Map<Team, SensorBaseClient> teams2sensorbases = new HashMap<Team, SensorBaseClient>();
  /** Maps teams to their DailyProjectDataClient instances. */
  private Map<Team, DailyProjectDataClient> teams2dailyprojectdatas = 
    new HashMap<Team, DailyProjectDataClient>();

  /**
   * Match default constructor.
   */
  public Match() {
    // empty match constructor.
  }
  
  /**
   * Clients must use the MatchManager to create Match instances. Performs validation as follows:
   * <ul>
   * <li>There must be at least one team in this match.
   * <li>The events list must be non-empty.
   * <li>Every team must have a project.
   * <li>Every team must have at least one member.
   * <li>The startTime and endTime must be within the Project interval.
   * <li>It must be possible to create SensorBase and DPD clients for each team.
   * </ul>
   * 
   * @param name The name of the Match.
   * @param startTime The start time.
   * @param endTime The end time.
   * @param teams2projects A set of tuples indicating the teams in this match and their Projects.
   * @param events The set of events in which the teams compete in this match.
   * @throws Exception If there is an error with Tstamp in validateTeamProjects.
   */
  Match(String name, XMLGregorianCalendar startTime, XMLGregorianCalendar endTime,
      Map<Team, Project> teams2projects, List<Event> events) throws Exception {
    this.name = name;
    this.startDateTime = startTime.toString();
    this.endDateTime = endTime.toString();
    this.teams2projects = teams2projects;
    this.events = events;
    setTeams2projects();
    validateEvents();
    validateTeamProjects();
    setupHackystatClients();
  }

  /**
   * Returns the unique id.
   * 
   * @return the id
   */
  public Long getId() {
    return id;
  }

  /**
   * Sets the unique id.
   * 
   * @param id the id to set
   */
  protected void setId(Long id) {
    this.id = id;
  }

  /**
   * Checks to make sure that the list of events for this match is non-empty.
   * 
   * @throws MatchException If there are no defined events for this match.
   */
  private void validateEvents() throws MatchException {
    if (this.events.isEmpty()) {
      throw new MatchException(String.format("Match %s has no defined events.", this.name));
    }
  }

  /**
   * Checks the validity of projects. This means:
   * <ul>
   * <li>Every team has a Project instance.
   * <li>Every member of the team is also a project member (but not necessarily vice-versa).
   * <li>The project start/end dates are greater than the match start/end dates.
   * </ul>
   * 
   * @throws Exception If there is an error with Tstamp.
   */
  private void validateTeamProjects() throws Exception {
    // Make sure this match contains at least one team.
    if ((this.teamProjects == null) || (this.teamProjects.keySet().isEmpty())) {
      throw new MatchException(String.format("Match %s has no teams.", this.name));
    }
    for (Entry<Team, TeamProject> entry : this.teamProjects.entrySet()) {
      Team team = entry.getKey();
      TeamProject teamProject = entry.getValue();
      Project project = ProjectManager.getInstance().getProject(teamProject.getName(), 
          teamProject.getOwner());
      // Make sure each team has a project.
      if (project == null) {
        throw new MatchException(String.format("Match %s has no project.", this.name));
      }
      for (User user : team.getMembers()) {
        String userEmail = user.getEmail();
        // Make sure everyone in the team is a project member or owner.
        if (!project.isMemberOrOwner(userEmail)) {
          String msg = String.format("Match %s has team %s with user %s not in project %s",
              this.name, team.getName(), userEmail, project.getName());
          throw new MatchException(msg);
        }
      }
      // Make sure that the match falls within the project start/end times.
      if (Tstamp.lessThan(Tstamp.makeTimestamp(this.startDateTime), project.getStartTime())) {
        String msg = String.format("Match %s has start time %s before project start time %s.",
            this.name, this.startDateTime, project.getStartTime());
        throw new MatchException(msg);
      }
      if (Tstamp.greaterThan(Tstamp.makeTimestamp(this.endDateTime), project.getEndTime())) {
        String msg = String.format("Match %s has end time %s after project start time %s.",
            this.name, this.endDateTime, project.getEndTime());
        throw new MatchException(msg);
      }
    }
  }

  /**
   * Sets up the SensorBase and DailyProjectData clients for each team.
   * 
   * @throws MatchException If the clients cannot be created and authenticated against the running
   * services.
   */
  private void setupHackystatClients() throws MatchException {
    // First, get the SensorBase, DPD, and Telemetry hosts from the Configuration.
    ConfigurationManager configManager = ConfigurationManager.getInstance();
    DevcathlonConfiguration config;
    try {
      config = configManager.getConfiguration();
    }
    catch (ConfigurationException e) {
      throw new MatchException("Error getting configuration", e);
    }
    String sensorbase = config.getHackystat().getSensorbase();
    String dailyprojectdata = config.getHackystat().getDailyprojectdata();

    // Now, for each team, create the associated client instances.
    for (Team team : this.teamProjects.keySet()) {
      // Each team must have at least one user!
      if (team.getMembers().isEmpty()) {
        throw new MatchException(String.format("Team %s has no users.", team));
      }
      // Any team user must have legal credentials for the client.
      User user = (User) team.getMembers().toArray()[0];
      String email = user.getEmail();
      String password = user.getPassword();

      // Make the SensorBaseClient for this team, validating the credentials.
      SensorBaseClient sensorBaseClient = new SensorBaseClient(sensorbase, email, password);
      try {
        sensorBaseClient.authenticate();
      }
      catch (Exception e) {
        throw new MatchException(String.format("Could not authenticate %s with %s", email,
            sensorbase), e);
      }
      this.teams2sensorbases.put(team, sensorBaseClient);

      // Make the DailyProjectDataClient for this team, validating the credentials.
      DailyProjectDataClient dpdClient = new DailyProjectDataClient(dailyprojectdata, email,
          password);
      try {
        dpdClient.authenticate();
      }
      catch (Exception e) {
        throw new MatchException(String.format("Could not authenticate %s with %s", email,
            dailyprojectdata), e);
      }
      this.teams2dailyprojectdatas.put(team, dpdClient);

      // Don't do the telemetry client until we start needing it. This speeds up testing.
      /*
       * TelemetryClient telemetryClient = new TelemetryClient(telemetry, email, password); try {
       * telemetryClient.authenticate(); } catch (Exception e) { throw new
       * MatchException(String.format("Could not authenticate %s with %s", email, telemetry), e); }
       * this.teams2telemetrys.put(team, telemetryClient);
       */
    }
  }

  /**
   * Returns the SensorBaseClient for this team. As long as the passed team is a member of this
   * match, this should reliably return the client.
   * 
   * @param team The team.
   * @return Their associated SensorBaseClient instance.
   * @throws MatchException when an error occurs.
   */
  public SensorBaseClient getSensorBaseClient(Team team) throws MatchException {
    // First, get the SensorBase, DPD, and Telemetry hosts from the Configuration.
    ConfigurationManager configManager = ConfigurationManager.getInstance();
    DevcathlonConfiguration config;
    try {
      config = configManager.getConfiguration();
    }
    catch (ConfigurationException e) {
      throw new MatchException("Error getting configuration", e);
    }
    String sensorbase = config.getHackystat().getSensorbase();
    
    // Any team user must have legal credentials for the client.
    User user = (User) team.getMembers().toArray()[0];
    String email = user.getEmail();
    String password = user.getPassword();

    // Make the SensorBaseClient for this team, validating the credentials.
    SensorBaseClient sensorBaseClient = new SensorBaseClient(sensorbase, email, password);
    
    return sensorBaseClient;
  }

  /**
   * Returns the DailyProjectDataClient for this team. As long as the passed team is a member of
   * this match, this should reliably return the client.
   * 
   * @param team The team.
   * @return Their associated DailyProjectDataClient instance.
   * @throws MatchException when an error occurs
   */
  public DailyProjectDataClient getDailyProjectDataClient(Team team) throws MatchException {
    // First, get the SensorBase, DPD, and Telemetry hosts from the Configuration.
    ConfigurationManager configManager = ConfigurationManager.getInstance();
    DevcathlonConfiguration config;
    try {
      config = configManager.getConfiguration();
    }
    catch (ConfigurationException e) {
      throw new MatchException("Error getting configuration", e);
    }
    String dailyprojectdata = config.getHackystat().getDailyprojectdata();
    
    // Any team user must have legal credentials for the client.
    User user = (User) team.getMembers().toArray()[0];
    String email = user.getEmail();
    String password = user.getPassword();

    // Make the DailyProjectDataClient for this team, validating the credentials.
    DailyProjectDataClient dpdClient = new DailyProjectDataClient(dailyprojectdata, email,
        password);
    try {
      dpdClient.authenticate();
    }
    catch (Exception e) {
      throw new MatchException(String.format("Could not authenticate %s with %s", email,
          dailyprojectdata), e);
    }
    return dpdClient;
  }

  /**
   * Return the start date for this Match.
   * 
   * @return The start date.
   * @throws Exception If there is an error with Tstamp
   */
  public XMLGregorianCalendar getStartTime() throws Exception {
    return Tstamp.makeTimestamp(this.startDateTime);
  }
  
  /**
   * Return the start time for this Match.
   * 
   * @return The start time.
   */
  public String getStartDateTime() {
    return this.startDateTime;
  }
  
  /**
   * Sets the start time for this Match.
   * 
   * @param startDateTime The start time.
   */
  public void setStartDateTime(String startDateTime) {
    this.startDateTime = startDateTime;
  }
  
  /**
   * Return the end date for this Match.
   * 
   * @return The end date.
   * @throws Exception If there is an error with Tstamp
   */
  public XMLGregorianCalendar getEndTime() throws Exception {
    return Tstamp.makeTimestamp(this.endDateTime);
  }
  
  /**
   * Returns the end time for this Match.
   * 
   * @return The end time.
   */
  public String getEndDateTime() {
    return this.endDateTime;
  }
  
  /**
   * Sets the end time for this Match.
   * 
   * @param endDateTime The end time.
   */
  public void setEndDateTime(String endDateTime) {
   this.endDateTime = endDateTime; 
  }

  /**
   * Get the total points for a user of team in match.
   * 
   * @param user The user.
   * @return userPoints Total user points for match.
   */
  public int getUserPoints(User user) {
    int totalScore = 0;
    for (EventScore s : getEventScores()) {
      if (s.getUser() == user) {
        totalScore = +getEvent(s).computePoints(s);
      }
    }
    return totalScore;
  }

  /**
   * Return the total score for a team.
   * 
   * @param team The team name.
   * @return The total score.
   */
  public int getTotalScore(Team team) {
    int totalScore = 0;
    if (team == null) {
      return totalScore;
    }
    for (Event event : events) {
      List<EventScore> eventScores = events2scores.get(event);
      if (eventScores != null) {
        for (EventScore eventScore : eventScores) {
          if (eventScore.getTeam().getName().equals(team.getName())) {
            totalScore += event.computePoints(eventScore);
          }
        }
      }
    }
    return totalScore;
  }

  /**
   * Get the total points for a user of team in match.
   * 
   * @param user The user.
   * @return userPoints Total user points for match.
   */
  public int getTotalScore(User user) {
    int totalScore = 0;
    if (user == null) {
      return totalScore;
    }
    for (Event event : events) {
      List<EventScore> eventScores = events2scores.get(event);
      if (eventScores != null) {
        for (EventScore eventScore : eventScores) {
          User eventScoreUser = eventScore.getUser();
          if (eventScoreUser.getEmail().equals(user.getEmail())) {
            totalScore += event.computePoints(eventScore);
          }
        }
      }
    }
    return totalScore;
  }

  /**
   * Return the set of teams in this Match.
   * 
   * @return The teams in this Match.
   */
  public Set<Team> getTeams() {
    return getTeamProjects().keySet();
  }
  
  /**
   * Sets the teams and projects map.
   */
  private void setTeams2projects() {
    for (Entry<Team, Project> entry : this.teams2projects.entrySet()) {
      TeamProject project = ProjectManager.getInstance().getTeamProject(entry.getValue().getName());
      this.teamProjects.put(entry.getKey(), project); 
    }
  }

  /**
   * @return the teamProjects
   */
  public Map<Team, TeamProject> getTeamProjects() {
    return teamProjects;
  }

  /**
   * Set the team projects map.
   * 
   * @param teamProjects The team projects map.
   */
  protected void setTeamProjects(Map<Team, TeamProject> teamProjects) {
    this.teamProjects = teamProjects;
  }

  /**
   * Return the Project associated with the given Team for this match. As long as the team is part
   * of this match, then a project should be returned.
   * 
   * @param team The team whose project is to be retrieved.
   * @return The project.
   */
  public Project getProject(Team team) {
    TeamProject project = this.teamProjects.get(team);
    return ProjectManager.getInstance().getProject(project.getName(), project.getOwner());
  }

  /**
   * Return the list of Events for this Match. Careful! Do not modify this list.
   * 
   * @return The list of Events in this match.
   */
  public List<Event> getEvents() {
    return this.events;
  }

  /**
   * Sets the event list for new events to an existing match.
   * 
   * @param newEvents The list of events.
   */
  public void setEvents(List<Event> newEvents) {
    this.events = newEvents;
  }

  /**
   * Add a timer task for an event to the match.
   * 
   * @param event The event associated with the timer.
   * @param timer The timer to be added.
   */
  public void addTimer(Event event, Timer timer) {
    this.events2timers.put(event, timer);
  }

  /**
   * Gets a timer task for an event to the match.
   * 
   * @param event The event associated with the timer.
   * 
   * @return The timer associated with the event.
   */
  public Timer getTimer(Event event) {
    return this.events2timers.get(event);
  }

  /**
   * Removes timer task for an event to the match.
   * 
   * @param event The event associated with the timer.
   */
  public void removeTimer(Event event) {
    this.events2timers.remove(event);
  }

  /**
   * Returns the Timer associated with the given Event.
   * 
   * @return The Collection of timers.
   */
  public Collection<Timer> getAllTimer() {
    return events2timers.values();
  }

  /**
   * Return the (unique) name of this match.
   * 
   * @return The match name.
   */
  public String getName() {
    return this.name;
  }

  /**
   * Sets the name of this match.
   * 
   * @param name The match name.
   */
  protected void setName(String name) {
    this.name = name;
  }
  
  /**
   * Returns the event scores map.
   * 
   * @return the eventScoresMap
   */
  public Map<EventScore, Event> getEventScoresMap() {
    return eventScoresMap;
  }

  /**
   * Sets the event scores map.
   * 
   * @param eventScoresMap the eventScoresMap to set
   */
  public void setEventScoresMap(Map<EventScore, Event> eventScoresMap) {
    this.eventScoresMap = eventScoresMap;
  }

  /**
   * Add an event score to a match.
   * 
   * @param event The event that the score is assigned to.
   * 
   * @param score The Event Score to be added.
   */
  public void addScore(EventScore score, Event event) {
    if (null == eventScoresMap.get(score)) {
      getEventScoresMap().put(score, event);
    }
    else {
      getEventScoresMap().remove(score);
      getEventScoresMap().put(score, event);
    }
  }

  /**
   * Returns all the event scores in a match.
   * 
   * @return A set of all event scores.
   */
  public Set<EventScore> getEventScores() {
    return eventScoresMap.keySet();
  }

  /**
   * Returns the event associated with the score.
   * 
   * @param score The score whose event is to be retrieved.
   * 
   * @return The event.
   */
  public Event getEvent(EventScore score) {
    return eventScoresMap.get(score);
  }

  /**
   * Add an event score to a match.
   * 
   * @param event The event that the score is assigned to.
   * 
   * @param score The Event Score to be added.
   */
  public void addScore(Event event, EventScore score) {
    addScore(score, event);

    List<EventScore> e;
    if (this.events2scores.get(event) == null) {
      e = new ArrayList<EventScore>();
      e.add(score);
    }
    else {
      e = this.events2scores.get(event);
      e.add(score);
    }
    this.events2scores.put(event, e);
  }

  /**
   * Returns a map of all matches.
   * 
   * @return the names2matches The map of matches.
   */
  public Map<Event, List<EventScore>> getAllEventScores() {
    return events2scores;
  }

  /**
   * Returns a list of event scores for a match.
   * 
   * @param event The event whose scores are to be retrieved.
   * 
   * @return the list of event scores.
   */
  public List<EventScore> getEventScores(Event event) {
    return events2scores.get(event);
  }

  /**
   * Add a comment to a match.
   * 
   * @param comment The comment to be added.
   */
  public void addComment(Comment comment) {
    comments.add(comment);
  }

  /**
   * Return the list of Comments for this Match.
   * 
   * @return The list of Comments in this match.
   */
  public List<Comment> getComments() {
    return this.comments;
  }

  /**
   * Sets the comments for this match.
   * 
   * @param comments the comments to set
   */
  protected void setComments(List<Comment> comments) {
    this.comments = comments;
  }

  /**
   * Returns a printable string of the match's end date.
   * 
   * @return The date in a string (mm/dd/yyyy).
   * @throws Exception If there is an error with the timestamp.
   */
  public String printEndDateTime() throws Exception {
    DateFormat formatter = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT,
        new Locale("en", "US"));
    return formatter.format(this.getEndTime().toGregorianCalendar().getTime());
  }

  /**
   * Returns a printable string of the match's start date.
   * 
   * @return The date in a string (mm/dd/yyyy).
   * @throws Exception If error with timestamp.
   */
  public String printStartDateTime() throws Exception {
    DateFormat formatter = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT,
        new Locale("en", "US"));
    return formatter.format(this.getStartTime().toGregorianCalendar().getTime());
  }

  /**
   * Get the total points of a team.
   * 
   * @param team The team.
   * @return teamPoints The total amount of points for the team.
   */
  public Integer getTeamPoints(Team team) {
    Integer teamPoints = 0;
    for (EventScore s : getEventScores()) {
      if (s.getTeam() == team) {
        teamPoints += getEvent(s).computePoints(s);
      }
    }
    return teamPoints;
  }

  /**
   * Checks if match is completed by comparing today's timestamp to the end date.
   * 
   * @return Returns true if the match is completed, false otherwise.
   */
  public boolean isCompleted() {
    try {
      XMLGregorianCalendar now = Tstamp.makeTimestamp();
      if (Tstamp.greaterThan(now, this.getEndTime())) {
        return true;
      }
      return false;
    }
    catch (Exception e) {
      // bad timestamp;
      return false;
    }
  }

  /**
   * Returns the name(s) of the winning team.
   * 
   * @return The list of names of the winning team.
   */
  public List<String> getWinningTeams() {
    int highestScore = 0;
    List<String> winningTeams = new ArrayList<String>();

    for (Team team : this.getTeams()) {
      if (this.getTeamPoints(team) > highestScore) {
        winningTeams.clear();
        highestScore = this.getTeamPoints(team);
        winningTeams.add(team.getName());

      }
      else if (this.getTeamPoints(team) == highestScore) {
        winningTeams.add(team.getName());
      }
    }
    return winningTeams;
  }

  /**
   * Sets the team's invitation status for the match as accepted.
   * 
   * @param team The team who accepted the match invite.
   */
  public void acceptMatchInvitation(Team team) {
    this.teamsInviteMap.put(team, "accepted");
    EventManager.getInstance().createEventTasks(this, events);
  }

  /**
   * Sets the team's invitation status for the match as declined and remove all appearance of that
   * team in the match.
   * 
   * @param team The team who declined teh match invite.
   */
  public void declineMatchInvitation(Team team) {
    this.teamsInviteMap.put(team, "declined");
    // this.teams2projects.remove(team);
    this.teams2sensorbases.remove(team);
    this.teams2dailyprojectdatas.remove(team);

  }

  /**
   * Gets the team's invitation status for the match.
   * 
   * @param team The team to look up.
   * @return The status of the invitation ("accepted", "declined", or null).
   */
  public String getInvitationStatus(Team team) {
    return this.teamsInviteMap.get(team);
  }

  /**
   * Checks if all teams have accepted the invitations. Use this method to check if match can start.
   * 
   * @return True if all team have responded to the invitation.
   */
  public boolean allInvitedTeamsReady() {
    for (Team team : teamProjects.keySet()) {
      if (!this.teamsInviteMap.containsKey(team)) {
        return false;
      }
    }
    return true;
  }

  /**
   * Returns the teams invite map.
   * @return The team invite map.
   */
  public Map<Team, String> getTeamsInviteMap() {
    return teamsInviteMap;
  }

  /**
   * Sets the team invite map.
   * @param teamsInviteMap The team invite map.
   */
  public void setTeamsInviteMap(Map<Team, String> teamsInviteMap) {
    this.teamsInviteMap = teamsInviteMap;
  }
  
  /**
   * Override equals in order to compare Match objects.
   * 
   * @param obj The Match that will be compared.
   * @return If Match object is null or wrong type returns false. Else if Matches are the same
   * returns true.
   */
  public boolean equals(Object obj) {
    if (!(obj instanceof Match)) {
      return false;
    }
    Match c = (Match) obj;
    return c.name.equals(this.name);
  }

  /**
   * Override hashCode.
   * 
   * @return Arbitrary constant since hashCode isn't used.
   */
  @Override
  public int hashCode() {
    assert false : "hashCode not designed";
    return 1; // any arbitrary constant will do
  }
}
